"""This module defines the PyMakContext object. This object is used to
hold the global state for a PyMak context. One context is usually
associated with a directory in the source tree.

It is also used to import pymak files in sub-directories of a component.
"""

# Python imports
import sys
import os
import getopt
import logging
import logging.config

# Package imports
import componentxml
import variant
import pmutils
from path import path, to_path
import target
import rule
import properties

__all__ = [
    'PyMakContext',
    ]

# Something unqiue to find arguments not supplied to a kwargs method.
_NOT_SPECIFIED = object()

class PyMakContext(object):
    """The top-level PyMakContext object is constructed before any
    pymak files are called, and supplied in the global namespace as
    'pmc' (pymak context). PyMak files would construct other context
    objects by calling import_sub_pymak_files method to read in pmk
    files from directories underneath the top level pmk file.

    This object contains all the state of the PyMak required for that
    directory, and provides a number of house-keeping functions, as
    well as factory methods.
    """
    def __init__(self, parent_context=None, context_dir=''):
        """PyMakContext constructor. The root context created in
        pm4 simply uses the default arguments: no parent context,
        and the empty path means the current directory is used
        for the context path.

        The main reason for multiple contexts is the reading in of pmk
        files in folders beneath the top pmk file. Since the method
        import_sub_pymak_files does this, it's unlikely that pmk files
        will need to construct PyMakContext objects directly.
        """
        # attributes reflecting command line arguments
        self.verbosity = 0
        self.variants = variant.VariantValues()
        self.build_dependencies = False
        self.restart_dependencies = True
        self.keep_going = False
        self.build_targets = []
        self.debug_shell = False

        # State attributes
        self.parent_context = parent_context

        """This dictionary attribute holds all the targets that have
        been created in this context, keyed by the target path of the
        target.
        """
        self.all_targets = {}

        """This dictionary attribute hold all the Rules introduced by
        pymak files. After defining a Rule class, an instance of this
        class should be inserted into this dictionary, e.g.

        pymak.declare_rule(FooBarRule('.foo.bar'))

        The name of the rule can be anything unique - it's just
        convention that the string chosen looks like regular make
        rules.
        """
        self.all_rules = {}

        """ This dictionary holds all the actions introduced by pymak
        files. After defining an Action class, an instance of this
        class can be added into this dictionary, e.g.
        context.define_action('.foobar', FooBarAction())
        """
        self.all_actions = {}

        """ This dictionary-like attribute holds all the properties of
        the system. The key to this dictionary is the property name. A
        key's values is a binding-list. A binding list is, not
        surprizingly, a list of bindings. A binding is a VariantValues
        instance.
        """
        self.all_properties = properties.Properties()

        """This dictionary like attribute holds the list of
        subcomponents that have been either defined explictily through
        methods on this objects, or have been set up using the method
        read_import_file, which initializes it from the component-build
        files.
        """
        self.subcomponents = pmutils.ImportsDict()

        """This dictionary-like attribute describes the thirdparty
        pre-requisites that have been defined for building. The method
        read_installed_spec updates this dictionary from files written
        out by QPBuild.
        """
        self.thirdparty_installs = pmutils.InstallsDict()

        """The context path is used to define the folder in which this
        context operates. When a target is expanded, this path is
        prepended to the path of the target itself to create a real
        path relative to the top-level context.
        """
        self.path = to_path(context_dir)

        """Re-enterant guard on the debugging shell."""
        self._in_shell = False

        
        if parent_context:
            # This is a sub-context, and therefore copy some of the context from its parent
            self.subcomponents = parent_context.subcomponents
            self.thirdparty_installs = parent_context.thirdparty_installs
            self.variants = parent_context.variants
            self.build_dependencies = parent_context.build_dependencies
            self.restart_dependencies = parent_context.restart_dependencies
            self.keep_going = parent_context.keep_going

        # create the empty rule
        self.declare_rule('null', rule.Rule(self))

        # create the default set of targets if this is the root context
        if not parent_context:
            for n in ['null', 'predepend', 'depend', 'all']:
                self.target(n, rule=None)
        
    def set_variants_from_xml(self, fname=None, exclude=[]):
        """This method is usually called from the top-level makefile
        of a component.  It reads the component xml file for that
        component to get the list of variants, the possible values and
        their default values. It finally calls set_variants() to
        reconcile that list of variants with the those defined in this
        context.
        """
        if not fname:
            fname = [ f for f in os.listdir('.') if f.endswith('-component.xml') or f.endswith('-product.xml') ]
            if len(fname) != 1:
                raise pmutils.PyMakException("Could not find default component xml file")
            fname = fname[0]
        variant_values = componentxml.get_variants_from_component_file(fname, exclude)
        return self.set_variants(variant_values)

    def set_variants(self, variant_values):
        """This method reconciles the list of VariantValues supplied
        with the existing set of variants in this context. It return
        the list of variant names.
        """
        for vv in variant_values:
            self.variants.reconcile(vv)

        self.variants.sort()

        return self.variants.names()

    def set_variants_from_dict(self, var_dict):
        for k, v in var_dict.items():
            vv = variant.new_variant_value(k, list(v), v[0])
            self.variants.reconcile(vv)
        self.variants.sort()
        return self.variants.names()
            


    # Returns a generator providing a list of ini file path that should be read
    def __ini_files(self):
        """Internal method to return a generator return the possible
        locations of logging ini files.
        """
        yield path.getcwd() / 'pymak_logging.ini'
        yield to_path(__file__).parent.parent / 'resources' / 'pymak_logging.ini'

    def parse_args(self):
        """This method is called by the pm4 wrapper to initialize the
        context from the command line arguments passed in. It is
        unlikely to be useful to PyMak files themselves.

        It attempts to load a file called pmlogging.ini in the current
        directory, and if one doesn't exist there, in the directory
        where the pymak package is stored.

        Furthermore, the -V (verbose) flag modifies the logging level
        of the root logger. This is done _after_ the logging ini file
        is loaded. No -V sets the logging level to WARNING, one -V
        sets it to INFO, and two or more set it to DEBUG.

        The method returns True on success and False if there is a
        problem parsing the command line. The usage string will have
        been printed already.
        """
        # find a logging file to load
        for ini in self.__ini_files():
            if ini.isfile():
                logging.config.fileConfig(ini)
                break
            
        try:
            args, rest = getopt.getopt(sys.argv[1:], "dDVkv:rs", ["keep-going", "depend", "release", "shell"]);
        except:
            return self.usage()
        for a in args:
            if a[0] == "-V":
                self.verbosity += 1
            elif a[0] == "-v":
                s = a[1].split("=")
                if len(s) != 2 or len(s[0]) == 0 or len(s[1]) == 0:
                    return self.usage()
                name, value = s
                self.variants.reconcile(variant.new_variant_value(name, [value], value))
            elif a[0] in ("-r", "--release"):
                self.variants.reconcile(variant.new_variant_value('style', ['rel'], 'rel'))
            elif a[0] in ("-d", "--depend"):
                self.build_dependencies = True
            elif a[0] == "-D":
                self.build_dependencies = True
                self.restart_dependencies = False
            elif a[0] in ("--keep-going", "-k"):
                self.keep_going = True
            elif a[0] in ("--shell", "-s"):
                self.debug_shell = True
            else:
                return self.usage()
        if len(rest) == 0:
            self.build_targets = ["all"]
        else:
            self.build_targets = rest

        # set the level of the root logger.
        lvl = {0: logging.WARNING, 1: logging.INFO, 2: logging.DEBUG}.get(self.verbosity, logging.DEBUG)
        logging.getLogger().setLevel(lvl)

        return True

    def usage(self):
        """Issues a command line usage string using the default root
        logger's error funtion, and then always returns False as a
        convenience to it's caller.
        """
        logging.error(_("Usage: %s [-k|--keep-going] [-V] [-r|--release] [-d|--depend] [-D] [-v variant-name=variant-value]* [target]*"), sys.argv[0])
        logging.error(_("  A single -V will show build commands. More than one will show included makefiles."))
        logging.error(_("  More than two will dump the dependency tree (which can be huge!) before building it."))
        logging.error(_("  -r|--release sets the variant 'style' to 'rel'"))
        logging.error(_("  -d|--depend will build dependencies before running the build of a target."))
        logging.error(_("  -D will build dependencies before running the build of a target, but wont"))
        logging.error(_("     force a restart of building all dependencies."))
        logging.error(_("  To build a release version, for example, use: -r -v foo=bar"))
        return False

    def import_pmk_files(self):
        """This method looks for '.pymak' files in the context's
        directory. It then executes them (sorted alphabetically) in
        the context of a globals dictionary and local dictionary. The
        globals one consists of the following two entries:

        - pmc: this context object
        - pymak: the pymak module

        And the locals one is an empty dictionary.
        """
        # see if we can find any .pmc files
        p = to_path('.')
        try:
            imports = p.listdir('*.pymak')
            if not imports:
                raise OSError()
        except OSError, e:
            raise pmutils.PyMakException("Could not find a \".pymak\" file in the directory '%s'" % self.path,
                                         underlying_exception=e)

        # sort 'em so that if multiple files exist, then their order is predictable
        imports.sort()

        globs = {'pmc': self, 'pymak': sys.modules['pymak'] }
        locs = {}

        # exec each in turn
        for m in imports:
            execfile(m, globs, locs)


    def import_sub_pymak_files(self, dir_path):
        """This method is used to import pmk files from directories under the
        current pmk file. What this method does is:

        - Create a new context object, whose parent_context is this context, and
        whose context dir is dir_path
        - changes current dir to dir_path
        - invokes import_pmk_files on the new context
        - reverts the current dir to its original value

        It returns the new context.
        """
        dir_path = to_path(dir_path)
        new_context = PyMakContext(self, dir_path)
        cur_dir = path.getcwd()
        dir_path.chdir()
        try:
            new_context.import_pmk_files()
            return new_context
        finally:
            cur_dir.chdir()


    def read_installed_spec(self, variant_names=None, fname="install-$(allvariants)"):
        """Read in the file that qpbuild writes out to describe all the
        required thirdparty tools/packages specified in the component
        XML file. Set up env var for each entry."""
        if variant_names is None:
            variant_list = self.variants
	else:
            variant_list = self.variants.sub(variant_names)

        result = pmutils.expand_variables(fname, variant_list, self)
	try:
            f = open(result, "r")
	except IOError, e:
            raise pmutils.PyMakException("Could not read file '%s'. Run pbuild first." % result, e)
        installs = self.thirdparty_installs
	for line in f.readlines():
            if line:
                key, rest = line.split(": ")
                p = path(rest.strip())
                installs[key] = p
                logging.debug("Install: %s=%s", key, p)
                # And now set the environ to have this reference
                os.environ[key] = p
	return installs

    def read_import_file(self, variant_names=None, fname="import-$(allvariants)"):
        """QPBuild can write out an import file for all the sub-components of this component.
        (Use the import-file-path attrib of the PART-SPEC-LIST element). This function will
        read this file in, and return a dictionary keyed by the sub-component name. The value
        will be a list of paths.

        This dictionary is in fact a slight smarter dictionary than a vanilla one. See the
        documentation for the ImportsDict class."""
        
        if variant_names is None:
            variant_list = self.variants
        else:
            variant_list = self.variants.sub(variant_names)
        result = pmutils.expand_variables(fname, variant_list, self)
        try:
            f = open(result, "r")
        except IOError, e:
            raise pmutils.PyMakException("Could not read file '%s'. Run pbuild first." % result, e)
        imps = self.subcomponents
        for line in f.readlines():
            if line:
                key, rest = line.split(": ")
                if key == 'NAMESPACE':
                    imps.namespace = rest.strip()
                else:
                    imps[key] = [ path(s) for s in rest.strip().split(os.path.pathsep) ]
                    logging.debug("Import: %s=%s", key, imps[key])
        return imps

    def import_pymak_extension(self, component_name, path, module_name,
                               major_ver=None, minor_ver=None,
                               *args, **kwargs):
        """A PyMak extension is really just a Python module. It is
        normally found by refering to a sub-component which has been
        reference in the components xml file and introduced unto PyMak
        with the read_import_file. It generally extends the capability
        of PyMak. This method allows these extensions to be
        conveniently loaded.

        - component_name: This is the component name that gets looked
        up in the dictionary-like object that is created from the
        read_import_file method. If this value is None, then the
        standard extensions folder is assumed (the path returned by
        standard_extensions_folder()).

        - path: This is the relative path from the component directory
        to where the module exists. Essentially the component
        directory and this path are concatenated and then put onto the
        Python sys.path. The path can either be a folder in which the
        module can be found, or a zip file which contains the module.

        - module_name: This is the name of the module that gets
        imported.

        The module is then imported. If it cannot be found, then the
        exception is not handled at this level - i.e. the caller will
        need to handle it if it needs to. Typically it is more useful
        for the exception to be caught and reported at the top level
        of the PyMak since this error would normally be considered
        fatal.

        If the module has a function called init() defined in it, it
        is then called with the context as the first argument followed
        by the args and kwargs supplied to this method. Any return
        value from the method is ignored.

        If the module has a function called version_ok(), and the
        major_ver and minor_ver arguments are supplied, then that
        function is called with these versions as arguments. If the
        version_ok() function returns False, then a PyMakException is
        thrown back to the caller.

        Finally the module itself is returned. This means that the
        caller can assign the module to a name suitable for their
        environment if it needs to. Since PyMak extensions tend to
        define rules and actions in their init() method and then
        declare them in the provided context, it is not always
        necessary to assign the module to a local variable in order to
        benefit from the extension.

        Example:

        pycomp = pmc.import_pymak_extension('PYMAK_UTILS', 'pymak4utils', 'pythoncompile', 1, 0)

        This will add the folder 'pymak4utils', relative to the
        PYMAK_UTILS component's ship folder, to sys.path and then
        import the module called pythoncompile. After that module's
        init() function is called, it version_ok(1, 0) function is
        called. Definitions within that module can then be found by
        doing, for example:

        ..., action=pycomp.FooAction(), ...
        
        """
        if component_name:
            sub_comp_dir = self.subcomponents(component_name, pmutils.SINGLETON_SUBCOMPONENT)
        else:
            sub_comp_dir = self.standard_extensions_folder()
        if path:
            sub_comp_dir = sub_comp_dir / path
        sys.path.append(sub_comp_dir)
        module = __import__(module_name)
        
        fn = getattr(module, 'init', None)
        if fn:
            fn(self, *args, **kwargs)
        fn = getattr(module, 'version_ok', None)
        if fn and major_ver is not None and minor_ver is not None:
            if not fn(major_ver, minor_ver):
                raise pmutils.PyMakException("Wrong version when loading module %s" % module_name)
        return module

    def standard_extensions_folder(self):
        p = to_path(__file__) / '..' / '..' / 'extensions'
        return p.abspath()


    def target(self, tpath, **kwargs):
        """This method is the main 'factory' used to construct a
        Target object, and is used frequently in pmk files.  The
        strategy it implements is:
        
        - if a Target object has been created previously with the same
        path as the supplied path (i.e. it is in the all_targets
        attribute), then that target object is passed to the
        update_target method on the associated rule along with the
        rest of the arguments. This target object is then returned.

        - if no target is found, then the associated rule is found,
        and the apply_rule method on it is called. This should return a
        target, which is then in turn returned by this method.

        The following points describe the strategy for determining the
        associated rule refered to above:

        - if a rule has been specified in the args
        (rule=<Rule-object>), then that Rule is used.

        - if a rule has been speficied in the args (rule=<string>),
        then the rule is found by looking up the string in the context's rules, and it
        is used.

        - if no rule has been specified, then a rule is looked up
        in the context's rules using the path's extension.

        - if a rule has been specified and set to None (rule=None),
        then the 'null' rule is used.

        - if no rule has been found after these tests have failed, then
        an error is generated.
        """
        _path = to_path(tpath)
        t = self.all_targets.get(_path)
        rul = kwargs.pop('rule', _NOT_SPECIFIED)
        if rul is _NOT_SPECIFIED:
            rul = self.all_rules.get(_path.ext)
            if not rul and t:
                rul = t._rule
            if not rul:
                raise pmutils.PyMakException("Failed to find a rule for path '%s'" % _path)
        elif isinstance(rul, basestring):
            r = self.all_rules.get(rul)
            if not r:
                raise pmutils.PyMakException("Failed to find a rule for '%s'" % rul)
            rul = r
        elif isinstance(rul, rule.Rule):
            pass
        elif rul is None:
            if t:
                rul = t._rule
            if not rul:
                rul = self.all_rules.get('null')
        else:
            raise pmutils.PyMakException("Rule is of wrong type: %s" % str(rul))

        if t:
            rul.update_target(t, **kwargs)
        else:
            t = rul.apply_rule(_path, **kwargs)
        t._rule = rul

        return t

    def declare_rule(self, name, rul):
        """Add a rule to the all_rules attribute of this context."""
        self.all_rules[name] = rul

    def alias_rule(self, alias, original):
        """Declares an existing rule under a new alias."""
        self.all_rules[alias] = self.all_rules[original]

    def declare_action(self, name, action):
        """Add an action to the all_rules attribute of this context."""
        self.all_actions[name] = action

    def define_property(self, name, variants, value):
        """Add or update a property ikn this context."""
        self.all_properties.add_property(name, variants, value)

    def lookup_property(self, name, variants):
        """Looks up the property in the all_properties table."""
        return self.all_properties.lookup_property(name, variants, self.path)

    def stringify_property(self, name, variants, prefix, sep=" ", prefix_sep=" "):
        """This method looks up the property 'name' in the context's all_properties
        table, and then returns a string which consists of a concatination (separated by
        'sep') of the following:

            prefix + prefix_sep + value

        for each value in the property. The most common use of this is to add
        elements like this to the command line of an action:

            context.stringify_property('CINCLUDES', variants, '-I')

        which will return the string:

            "-I foo -I bar -I baz"

        where the property 'CINCLUDES' is bound to ('foo', 'bar', 'baz').
        """
        return sep.join([ prefix + prefix_sep + v for v in self.lookup_property(name, variants) ])
        
    def build_world(self):
        """ This function gets called by the pm4 wrapper. It will
        build all the targets mentioned in the command line using the
        list of variants specified in the command line, or their
        defaults. If dependency generation has been requested, then
        these are built first, otherwise the output of a previous
        dependency generation needs to be consumed for each target
        mentioned on the command line (if they exist).
        """

        if self.debug_shell:
            self.debug() # this blocks until the user exits PyCrust
        
        done_pre_depend = False
        done_depend = False
        for tname in self.build_targets:
            # 'clean' is a target name which skips out the dependencies stuff
            if tname != 'clean':
                if not done_pre_depend:
                    self._build('predepend')
                    done_pre_depend = True

                if not done_depend:
                    self._build('depend')
                    done_depend = True

                self.all_targets['depend'].read_all_dependency_files(self.variants)

            self._build(tname)
            

    def _build(self, tname):
        target = self.all_targets.get(tname)
        if not target:
            raise pmutils.PyMakException("Failed to find target '%s'" % tname)

        target._build()


    def debug(self):
        """This method requests that an interactive shell is popped up
        which allows the user to explore the current namespace, run
        interactive Python commands, and see embedded documentation of
        the PyMak world.

        The shell is based on the PyCrust shell that ships as part of
        wxPython framework. An install of this is needed in order to
        use this feature. If one cannot be found, then a
        PyMakException is generated, and the build fails.

        There are two ways to create this shell:

        1) The -s option on the command line will pop up a PyCrust
        shell just before the command-line requested targets are built
        (i.e. in build_world()).

        2) By editing the source pmk file and placing the code:

            pmc.debug()

        wherever the user needs to do some interactive probing to help
        them resolve a problem.

        The debug() method is protected against re-entrancy so that
        multiple shells are not created simultaneously.

        When the shell pops up, the user can type any Python they
        choose at the prompt, or simply navigate through the namespace
        using the tree viewer. Typical Python that might be used is:

        1) Debugging the attributes of a target:

        >>> t = pmc.target('foo')

        which will find a target named 'foo'. If one does not exist an
        error will be returned. In order to create one named 'foo',
        supply a rule as an argument. The user can then look at the
        attributes of the target, such as:

        >>> t.path

        >>> t.path.exists()

        >>> t.path.parent
        
        >>> t._action
        
        >>> t._depends_on.list_of_sub_targets()

        Looking at all the defined targets:

        >>> pmc.all_targets.keys()

        2) Actions:

        >>> pmc.all_actions.keys()

        >>> pmc.all_actions['null']

        3) Variants

        >>> pmc.variants

        >>> str(pmc.variants)

        PyCrust attempts to display pydoc and command completion as
        best it can. This is an ideal way to learn how the PyMak
        environment works.
        """
        if self._in_shell:
            return
        # Try and find the wx install
        try:
            from wx.py.PyCrust import main
            shell = main
        except ImportError, e:
            logging.error("Could not find a wx install to run the PyCrust shell.")
            logging.error("Using Pdb instead.")
            import pdb
            shell = pdb.Pdb().set_trace
            pmc = self # not sure why I have to stuff this here for 'pmc' to be seen.
        # and run whichever shell we can
        try:
            self._in_shell = True
            shell()
        finally:
            self._in_shell = False
